Skip to content

[Feat/like-21] ✨ feat: 좋아요 기능 추가#99

Merged
swallowedB merged 4 commits intodevfrom
feat/like-21
Jan 14, 2026
Merged

[Feat/like-21] ✨ feat: 좋아요 기능 추가#99
swallowedB merged 4 commits intodevfrom
feat/like-21

Conversation

@swallowedB
Copy link
Owner

@swallowedB swallowedB commented Jan 14, 2026

요약

  • 변경 목적(왜?):
    • B-log 게시글에 비로그인 사용자도 좋아요를 누를 수 있는 기능을 추가하기 위함
    • 사용자별로 동일 게시글에 1회만 좋아요 가능하도록 제약 필요
  • 주요 변경(무엇을?):
    • Supabase 기반의 post_likes 테이블 + UNIQUE 제약 + RLS 정책 구성
    • Next.js 클라이언트 측에서 viewer 식별 및 좋아요 토글 로직 구현

변경 내용

  • UI/컴포넌트

    • 좋아요 버튼(PostLikeButton) 추가
    • liked 상태에 따른 UI 스타일 처리
    • 카운트 표시 및 로딩 상태 처리
  • 로직/유틸

    • Supabase RLS 활성화 및 정책 설정
    • post_likes(post_id, viewer_id) UNIQUE 제약 추가
    • 클라이언트 Supabase client 생성 (supabase/client.ts)
    • 익명 사용자 식별용 viewer_id 생성 (viewerId.ts)
    • 좋아요 상태 관리 훅 usePostLike(postId) 구현
      • count/liked/로딩 상태 관리
      • insert/delete 기반 토글 처리
      • 중복 insert(23505) 처리
      • any 타입 제거 및 타입 안전 구조로 개선
  • 문서/설정


스크린샷/동영상 (선택)


테스트

  • 유닛 테스트 추가/수정됨 (추가 예정)
  • 로컬에서 동작 확인 (비로그인 좋아요 / 토글 / 새로고침 상태 유지)
  • 타입체크/린트 통과 (pnpm typecheck, pnpm lint)
  • Supabase 테스트 (insert/delete, UNIQUE 제약, 정책 등)

관련 이슈

close #21

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 사용자별 게시물 좋아요 상태 및 실시간 카운트 추적 기능 추가
  • 스타일

    • 헤더 검색 버튼 다크 모드 시각 개선
    • 좋아요·공유 버튼 및 액션 영역의 배경색·여백 조정
    • ⌘K 배지 간격 및 버튼 대비 개선
  • 잡무(Chores)

    • 레포지토리 라벨 및 일부 자동화 워크플로우 설정 정리/삭제

✏️ Tip: You can customize this high-level summary in your review settings.

@swallowedB swallowedB linked an issue Jan 14, 2026 that may be closed by this pull request
3 tasks
@vercel
Copy link

vercel bot commented Jan 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
b0o0a Ready Ready Preview, Comment Jan 14, 2026 8:44am

@coderabbitai
Copy link

coderabbitai bot commented Jan 14, 2026

📝 Walkthrough

Walkthrough

Supabase 연동 신규 훅 usePostLike을 추가하고, LikeButton을 훅 기반으로 리팩토링했으며 PostActions와 페이지에서 post 객체를 전달하도록 호출 시그니처를 변경했습니다. 헤더 및 일부 버튼의 스타일 조정과 GitHub 레이블/워크플로 삭제도 포함됩니다.

Changes

코호트 / 파일(들) 변경 사항
좋아요 훅 & 뷰어 ID 유틸
src/hooks/usePostLike.ts, src/lib/supabase/viewerId.ts
usePostLike(postId) 훅 추가: count, liked, loading, toggleLike, refreshCount 제공. Supabase 연동 쿼리 및 23505 중복 키 감지 로직 포함. getOrCreateViewerId() 추가: 브라우저 localStorage 기반 ID 생성/조회.
LikeButton 리팩토링
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx
로컬 상태 제거, usePostLike 사용으로 상태/토글 위임. postId prop 추가(PostLikeButtonProps). 로딩 중 비활성화, aria-pressed 바인딩, 애니메이션 유지, 표시되는 count는 훅 제공값 사용.
PostActions 호출 변경
src/app/(layout)/posts/[slug]/_components/PostActions.tsx, src/app/(layout)/posts/[slug]/page.tsx
PostActionspost: { slug } prop 추가 및 전달. LikeButtonpostId={post.slug} 전달하도록 변경. 컨테이너 배경 클래스 소폭 변경(bg-neutral-200/50bg-neutral-100).
스타일 조정(헤더·공유 버튼)
src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx, src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx
SiteHeader 검색 버튼 다크모드 배경/패딩 변경(dark:bg-background/40dark:bg-foreground/7, py-1.5py-1). ⌘K 배지 패딩 조정. ShareButton 아이콘 컨테이너 배경 토큰 변경(bg-neutral-100bg-background).
GitHub 레이블·워크플로 제거
.github/labeler.yml, .github/labels.yml, .github/workflows/labeler.yml, .github/workflows/labels-sync.yml, .github/workflows/ci.yml
레이블 구성과 레이블 동기/라벨러 워크플로 파일 삭제. CI에서 테스트 실행 스텝(pnpm test:ci) 제거.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant LikeButton
    participant usePostLike
    participant Supabase
    participant Browser

    User->>LikeButton: 클릭
    LikeButton->>usePostLike: toggleLike()
    alt 현재 liked == false
        usePostLike->>Browser: getOrCreateViewerId()
        usePostLike->>Supabase: insert post_likes (post_id, viewer_id)
        alt insert 성공
            Supabase-->>usePostLike: success
            usePostLike->>LikeButton: liked=true, count++
        else 중복키(23505)
            Supabase-->>usePostLike: duplicate error
            usePostLike->>usePostLike: liked=true, refreshCount()
        else 다른 에러
            Supabase-->>usePostLike: error
            usePostLike->>LikeButton: rollback, error
        end
    else 현재 liked == true
        usePostLike->>Supabase: delete from post_likes (post_id, viewer_id)
        Supabase-->>usePostLike: success
        usePostLike->>LikeButton: liked=false, count--
    end
    usePostLike->>LikeButton: loading=false 업데이트
    LikeButton->>User: UI 반영
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

좋아요가 DB로 춤추네 💃
훅이 손을 잡고 상태를 지키고
viewerId는 조용히 틈새를 지키며 🔐
옵티미스틱 한 걸음, 실패는 롤백으로
UI는 반응하고 사용자 경험은 숨쉬네 ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 3
❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning GitHub 워크플로우 및 라벨 구성 파일 제거가 좋아요 기능 구현과 무관한 변경으로 보입니다. .github/labeler.yml, .github/labels.yml, .github/workflows/ci.yml, .github/workflows/labeler.yml, .github/workflows/labels-sync.yml 제거를 별도 PR로 분리하거나 변경 필요성을 설명해 주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive PR이 #21의 주요 요구사항(Supabase 연동, optimistic UI, 중복 클릭 방지, 상태 관리)을 구현했으나 테스트 체크리스트 미완료가 있습니다. 유닛 테스트와 Supabase 통합 테스트를 추가하여 #21의 테스트 포인트를 검증해 주세요.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 좋아요 기능 추가라는 주요 변화를 명확히 나타내고 있습니다.
Description check ✅ Passed PR 설명이 템플릿 구조를 따르며 목적, 변경 내용, 테스트 상태를 포괄적으로 기술했습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings


📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 09974e7 and b8259f0.

📒 Files selected for processing (6)
  • .github/labeler.yml
  • .github/labels.yml
  • .github/workflows/ci.yml
  • .github/workflows/labeler.yml
  • .github/workflows/labels-sync.yml
  • src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx
💤 Files with no reviewable changes (5)
  • .github/labeler.yml
  • .github/workflows/ci.yml
  • .github/labels.yml
  • .github/workflows/labels-sync.yml
  • .github/workflows/labeler.yml
🧰 Additional context used
🧬 Code graph analysis (1)
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
src/hooks/usePostLike.ts (1)
  • usePostLike (19-127)
🔇 Additional comments (1)
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)

23-56: UI 구현이 깔끔합니다.

  • aria-pressed={liked} 접근성 속성 적용
  • liked 상태에 따른 조건부 스타일링 잘 구현됨
  • 다크모드 대응 클래스 포함

disabled={loading} 관련 개선 사항은 위 댓글에서 다루었습니다.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/(layout)/posts/[slug]/_components/PostActions.tsx (1)

4-11: 타입 정의 들여쓰기 불일치

post 속성의 들여쓰기가 다른 속성들과 일관되지 않습니다. 기능에는 문제없지만 가독성을 위해 정리하면 좋겠습니다.

✨ 제안하는 수정
 interface PostActionsProps {
   variant?: "desktop" | "mobile";
   title: string;
   thumbnail?: string;
-    post: {
-    slug: string
-  };
+  post: {
+    slug: string;
+  };
 }
🤖 Fix all issues with AI agents
In `@src/hooks/usePostLike.ts`:
- Around line 26-63: There’s a potential race/clarity issue splitting viewerId
initialization and like fetching into two useEffect blocks; combine them into a
single useEffect so you set viewerIdRef.current = getOrCreateViewerId() before
calling fetchLikeState, then run the fetch logic (previously in fetchLikeState)
using viewerIdRef.current (no fallback to getOrCreateViewerId inside the fetch)
and early-return if no viewerId or postId; keep the effect dependent on postId
and ensure you still set loading, count and liked as before (references:
viewerIdRef, getOrCreateViewerId, fetchLikeState, postId).
- Around line 81-119: toggleLike currently waits for the server before updating
UI and never sets a loading flag; implement optimistic UI by immediately
toggling the local state and count at the start of toggleLike, set a loading
state (e.g., setLoading(true)) to prevent double clicks, then perform the
supabase insert/delete calls; on server error rollback the local state/count
(use the prior liked and count values captured before the optimistic update),
handle duplicate-key error by setting liked=true and calling refreshCount(), and
always setLoading(false) in a finally block; refer to toggleLike, setLiked,
setCount, loading/setLoading, viewerIdRef, refreshCount, and isDuplicateKeyError
when making these changes.

In `@src/lib/supabase/viewerId.ts`:
- Around line 6-16: Wrap all accesses to window.localStorage in try/catch in the
viewer id initialization so exceptions (e.g., Safari private mode) don't break
the function: when reading VIEWER_ID_KEY, if localStorage.getItem throws, fall
back to an in-memory id variable; when generating id (using
window.crypto?.randomUUID or Date.now() fallback) still assign to that in-memory
variable and attempt to set localStorage inside its own try/catch so failures to
set are ignored; update references to id (the variable created in this block) so
the function returns/uses the id regardless of localStorage availability.
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 42e01c7 and 09974e7.

📒 Files selected for processing (7)
  • src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx
  • src/app/(layout)/posts/[slug]/_components/PostActions.tsx
  • src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx
  • src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx
  • src/app/(layout)/posts/[slug]/page.tsx
  • src/hooks/usePostLike.ts
  • src/lib/supabase/viewerId.ts
🧰 Additional context used
🧬 Code graph analysis (4)
src/app/(layout)/posts/[slug]/_components/PostActions.tsx (2)
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
  • LikeButton (12-57)
src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx (1)
  • ShareButton (12-50)
src/hooks/usePostLike.ts (2)
src/lib/supabase/viewerId.ts (1)
  • getOrCreateViewerId (3-19)
src/lib/supabase/client.ts (1)
  • supabase (3-6)
src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)
src/hooks/usePostLike.ts (1)
  • usePostLike (19-127)
src/app/(layout)/posts/[slug]/page.tsx (1)
src/app/(layout)/posts/[slug]/_components/PostActions.tsx (1)
  • PostActions (13-36)
🔇 Additional comments (7)
src/app/(layout)/(shell)/_components/layout/SiteHeader.tsx (1)

41-42: LGTM!

다크 모드 스타일링 변경이 적절해 보입니다. bg-foreground/7 토큰 사용과 패딩 조정이 다른 컴포넌트(ShareButton, LikeButton)의 스타일링 방향과 일관성을 유지하고 있습니다.

Also applies to: 48-50

src/app/(layout)/posts/[slug]/_components/post-actions/ShareButton.tsx (1)

38-44: LGTM!

bg-neutral-100에서 bg-background 토큰으로 변경하여 디자인 시스템 일관성이 개선되었습니다.

src/app/(layout)/posts/[slug]/page.tsx (1)

75-75: LGTM!

post 객체를 PostActions에 전달하여 LikeButtonpostId를 사용할 수 있게 되었습니다. PostActionsProps{ slug: string } 타입만 요구하므로, 전체 post 객체를 전달해도 TypeScript 구조적 타이핑에 의해 호환됩니다.

src/app/(layout)/posts/[slug]/_components/PostActions.tsx (1)

13-35: LGTM!

post.slugLikeButtonpostId로 전달하는 구조가 명확합니다. mobile과 desktop 두 variant 모두에서 일관되게 처리되고 있습니다.

PR 목표인 좋아요 기능의 핵심 흐름(PostActionsLikeButtonusePostLike)이 잘 연결되어 있습니다.

src/app/(layout)/posts/[slug]/_components/post-actions/LikeButton.tsx (1)

23-55: 버튼 UI 및 접근성 구현이 잘 되어 있습니다.

  • aria-pressed 사용으로 스크린 리더 지원
  • loading 상태에서 버튼 비활성화
  • 다크 모드 스타일링 적용
  • 애니메이션에 motion-safe 적용으로 접근성 고려
src/hooks/usePostLike.ts (2)

15-17: isDuplicateKeyError 헬퍼 함수 구현이 적절합니다.

PostgreSQL 에러 코드 23505(unique_violation)를 체크하여 중복 좋아요를 안전하게 처리하고 있습니다.


8-13: 타입 정의가 명확합니다.

UsePostLikeResult 타입이 훅의 반환값을 명확하게 정의하고 있어 타입 안전성이 좋습니다.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 17 to 21
const handleClick = () => {
setIsLiked((v) => !v);

toggleLike();
setIsPopping(true);
window.setTimeout(() => setIsPopping(false), 180);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

toggleLike()가 비동기 함수인데 await 없이 호출되고 있습니다.

현재 구현에서 두 가지 문제가 있습니다:

  1. 중복 클릭 방지 미흡: toggleLike()가 완료되기 전에 버튼을 다시 클릭할 수 있습니다. usePostLike 훅의 loading 상태가 toggleLike 실행 중에는 true로 설정되지 않아서 버튼이 비활성화되지 않습니다.

  2. 애니메이션 타이밍: 요청 성공/실패와 관계없이 애니메이션이 즉시 실행됩니다. 이슈 #21의 요구사항(optimistic UI + rollback on failure)을 고려하면, 실패 시 애니메이션 롤백도 고려해야 합니다.

🔧 제안하는 수정안
+  const [isPending, setIsPending] = useState(false);
+
-  const handleClick = () => {
-    toggleLike();
-    setIsPopping(true);
-    window.setTimeout(() => setIsPopping(false), 180);
+  const handleClick = async () => {
+    if (isPending) return;
+    setIsPending(true);
+    setIsPopping(true);
+    window.setTimeout(() => setIsPopping(false), 180);
+    try {
+      await toggleLike();
+    } finally {
+      setIsPending(false);
+    }
   };

그리고 버튼의 disabled 속성도 업데이트:

-      disabled={loading}
+      disabled={loading || isPending}

Comment on lines +26 to +63
useEffect(() => {
viewerIdRef.current = getOrCreateViewerId();
}, []);

useEffect(() => {
if (!postId) return;

const fetchLikeState = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) {
setLoading(false);
return;
}

const {
data,
count: total,
error,
} = await supabase
.from("post_likes")
.select("viewer_id", { count: "exact" })
.eq("post_id", postId);

if (error) {
console.error("좋아요 조회 실패:", error);
setLoading(false);
return;
}

const rows = data ?? [];

setCount(total ?? rows.length);
setLiked(rows.some((row) => row.viewer_id === viewerId));
setLoading(false);
};

void fetchLikeState();
}, [postId]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

두 개의 useEffect 간 경쟁 상태(Race Condition) 가능성이 있습니다.

첫 번째 useEffect(Line 26-28)에서 viewerIdRef를 설정하고, 두 번째 useEffect(Line 30-63)에서 이를 사용합니다. React의 useEffect는 선언 순서대로 실행되지만, 동일 렌더 사이클에서 실행되므로 첫 번째 effect가 완료된 후 두 번째가 실행되는 것이 보장됩니다.

다만 Line 34에서 viewerIdRef.current ?? getOrCreateViewerId() 폴백이 있어 실질적인 문제는 없지만, 두 개의 effect로 분리할 필요 없이 하나로 합치는 것이 더 명확합니다.

♻️ 단일 useEffect로 통합 제안
-  useEffect(() => {
-    viewerIdRef.current = getOrCreateViewerId();
-  }, []);
-
   useEffect(() => {
     if (!postId) return;
+    
+    viewerIdRef.current = getOrCreateViewerId();

     const fetchLikeState = async () => {
-      const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
+      const viewerId = viewerIdRef.current;
       if (!viewerId) {
         setLoading(false);
         return;
       }
       // ... rest of the code
     };

     void fetchLikeState();
   }, [postId]);
🤖 Prompt for AI Agents
In `@src/hooks/usePostLike.ts` around lines 26 - 63, There’s a potential
race/clarity issue splitting viewerId initialization and like fetching into two
useEffect blocks; combine them into a single useEffect so you set
viewerIdRef.current = getOrCreateViewerId() before calling fetchLikeState, then
run the fetch logic (previously in fetchLikeState) using viewerIdRef.current (no
fallback to getOrCreateViewerId inside the fetch) and early-return if no
viewerId or postId; keep the effect dependent on postId and ensure you still set
loading, count and liked as before (references: viewerIdRef,
getOrCreateViewerId, fetchLikeState, postId).

Comment on lines +30 to +63
useEffect(() => {
if (!postId) return;

const fetchLikeState = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) {
setLoading(false);
return;
}

const {
data,
count: total,
error,
} = await supabase
.from("post_likes")
.select("viewer_id", { count: "exact" })
.eq("post_id", postId);

if (error) {
console.error("좋아요 조회 실패:", error);
setLoading(false);
return;
}

const rows = data ?? [];

setCount(total ?? rows.length);
setLiked(rows.some((row) => row.viewer_id === viewerId));
setLoading(false);
};

void fetchLikeState();
}, [postId]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

비동기 작업의 cleanup이 없어 메모리 누수 가능성이 있습니다.

컴포넌트가 언마운트된 후에도 fetchLikeStatesetState 호출이 발생할 수 있습니다. 빠른 페이지 이동 시 경고가 발생할 수 있습니다.

♻️ AbortController 또는 cleanup flag 추가 제안
 useEffect(() => {
   if (!postId) return;
+  let isMounted = true;

   const fetchLikeState = async () => {
     const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
     if (!viewerId) {
+      if (isMounted) setLoading(false);
-      setLoading(false);
       return;
     }

     const { data, count: total, error } = await supabase
       .from("post_likes")
       .select("viewer_id", { count: "exact" })
       .eq("post_id", postId);

+    if (!isMounted) return;
+
     if (error) {
       console.error("좋아요 조회 실패:", error);
       setLoading(false);
       return;
     }

     const rows = data ?? [];

     setCount(total ?? rows.length);
     setLiked(rows.some((row) => row.viewer_id === viewerId));
     setLoading(false);
   };

   void fetchLikeState();
+
+  return () => {
+    isMounted = false;
+  };
 }, [postId]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!postId) return;
const fetchLikeState = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) {
setLoading(false);
return;
}
const {
data,
count: total,
error,
} = await supabase
.from("post_likes")
.select("viewer_id", { count: "exact" })
.eq("post_id", postId);
if (error) {
console.error("좋아요 조회 실패:", error);
setLoading(false);
return;
}
const rows = data ?? [];
setCount(total ?? rows.length);
setLiked(rows.some((row) => row.viewer_id === viewerId));
setLoading(false);
};
void fetchLikeState();
}, [postId]);
useEffect(() => {
if (!postId) return;
let isMounted = true;
const fetchLikeState = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) {
if (isMounted) setLoading(false);
return;
}
const {
data,
count: total,
error,
} = await supabase
.from("post_likes")
.select("viewer_id", { count: "exact" })
.eq("post_id", postId);
if (!isMounted) return;
if (error) {
console.error("좋아요 조회 실패:", error);
setLoading(false);
return;
}
const rows = data ?? [];
setCount(total ?? rows.length);
setLiked(rows.some((row) => row.viewer_id === viewerId));
setLoading(false);
};
void fetchLikeState();
return () => {
isMounted = false;
};
}, [postId]);

Comment on lines +81 to +119
const toggleLike = async () => {
const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
if (!viewerId) return;

if (liked) {
const { error } = await supabase
.from("post_likes")
.delete()
.eq("post_id", postId)
.eq("viewer_id", viewerId);

if (error) {
console.error("좋아요 취소 실패:", error);
return;
}

setLiked(false);
setCount((prev) => Math.max(0, prev - 1));
return;
}

const { error } = await supabase.from("post_likes").insert({
post_id: postId,
viewer_id: viewerId,
});

if (error) {
if (isDuplicateKeyError(error)) {
setLiked(true);
await refreshCount();
} else {
console.error("좋아요 추가 실패:", error);
}
return;
}

setLiked(true);
setCount((prev) => prev + 1);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Optimistic UI가 구현되지 않았습니다 (이슈 #21 요구사항).

이슈 #21에서 요구하는 "optimistic UI: update UI immediately on click and rollback if server fails"가 현재 구현되어 있지 않습니다. 현재는 서버 응답을 기다린 후에만 UI가 업데이트됩니다.

또한 toggleLike 실행 중 loading 상태가 true로 설정되지 않아서 컴포넌트에서 중복 클릭 방지가 어렵습니다.

🔧 Optimistic UI 및 loading 상태 추가 제안
 const toggleLike = async () => {
   const viewerId = viewerIdRef.current ?? getOrCreateViewerId();
   if (!viewerId) return;

+  // Optimistic update
+  const prevLiked = liked;
+  const prevCount = count;
+  
+  setLiked(!liked);
+  setCount((prev) => liked ? Math.max(0, prev - 1) : prev + 1);
+  setLoading(true);

   if (liked) {
     const { error } = await supabase
       .from("post_likes")
       .delete()
       .eq("post_id", postId)
       .eq("viewer_id", viewerId);

     if (error) {
       console.error("좋아요 취소 실패:", error);
+      // Rollback on failure
+      setLiked(prevLiked);
+      setCount(prevCount);
+      setLoading(false);
       return;
     }

-    setLiked(false);
-    setCount((prev) => Math.max(0, prev - 1));
+    setLoading(false);
     return;
   }

   const { error } = await supabase.from("post_likes").insert({
     post_id: postId,
     viewer_id: viewerId,
   });

   if (error) {
     if (isDuplicateKeyError(error)) {
       setLiked(true);
       await refreshCount();
     } else {
       console.error("좋아요 추가 실패:", error);
+      // Rollback on failure
+      setLiked(prevLiked);
+      setCount(prevCount);
     }
+    setLoading(false);
     return;
   }

-  setLiked(true);
-  setCount((prev) => prev + 1);
+  setLoading(false);
 };
🤖 Prompt for AI Agents
In `@src/hooks/usePostLike.ts` around lines 81 - 119, toggleLike currently waits
for the server before updating UI and never sets a loading flag; implement
optimistic UI by immediately toggling the local state and count at the start of
toggleLike, set a loading state (e.g., setLoading(true)) to prevent double
clicks, then perform the supabase insert/delete calls; on server error rollback
the local state/count (use the prior liked and count values captured before the
optimistic update), handle duplicate-key error by setting liked=true and calling
refreshCount(), and always setLoading(false) in a finally block; refer to
toggleLike, setLiked, setCount, loading/setLoading, viewerIdRef, refreshCount,
and isDuplicateKeyError when making these changes.

Comment on lines +6 to +16
let id = window.localStorage.getItem(VIEWER_ID_KEY);

if (!id) {
if (window.crypto?.randomUUID) {
id = window.crypto.randomUUID();
} else {
id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

window.localStorage.setItem(VIEWER_ID_KEY, id);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

localStorage 접근 시 예외 처리 고려 필요

일부 브라우저(Safari 프라이빗 모드 등)에서 localStorage 접근 시 예외가 발생할 수 있습니다. 현재 코드는 이 경우 전체 함수가 실패하게 됩니다.

🛠️ 제안하는 수정
 export function getOrCreateViewerId(): string | null {
   if (typeof window === "undefined") return null;
 
-  let id = window.localStorage.getItem(VIEWER_ID_KEY);
-
-  if (!id) {
-    if (window.crypto?.randomUUID) {
-      id = window.crypto.randomUUID();
-    } else {
-      id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+  try {
+    let id = window.localStorage.getItem(VIEWER_ID_KEY);
+
+    if (!id) {
+      if (window.crypto?.randomUUID) {
+        id = window.crypto.randomUUID();
+      } else {
+        id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+      }
+
+      window.localStorage.setItem(VIEWER_ID_KEY, id);
     }
 
-    window.localStorage.setItem(VIEWER_ID_KEY, id);
+    return id;
+  } catch {
+    // localStorage 접근 불가 시 (프라이빗 모드 등)
+    return null;
   }
-
-  return id;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let id = window.localStorage.getItem(VIEWER_ID_KEY);
if (!id) {
if (window.crypto?.randomUUID) {
id = window.crypto.randomUUID();
} else {
id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
window.localStorage.setItem(VIEWER_ID_KEY, id);
}
export function getOrCreateViewerId(): string | null {
if (typeof window === "undefined") return null;
try {
let id = window.localStorage.getItem(VIEWER_ID_KEY);
if (!id) {
if (window.crypto?.randomUUID) {
id = window.crypto.randomUUID();
} else {
id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
window.localStorage.setItem(VIEWER_ID_KEY, id);
}
return id;
} catch {
// localStorage 접근 불가 시 (프라이빗 모드 등)
return null;
}
}
🤖 Prompt for AI Agents
In `@src/lib/supabase/viewerId.ts` around lines 6 - 16, Wrap all accesses to
window.localStorage in try/catch in the viewer id initialization so exceptions
(e.g., Safari private mode) don't break the function: when reading
VIEWER_ID_KEY, if localStorage.getItem throws, fall back to an in-memory id
variable; when generating id (using window.crypto?.randomUUID or Date.now()
fallback) still assign to that in-memory variable and attempt to set
localStorage inside its own try/catch so failures to set are ignored; update
references to id (the variable created in this block) so the function
returns/uses the id regardless of localStorage availability.

@github-actions github-actions bot added the infra label Jan 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ feat: LikeButton — Supabase 연동 + Optimistic UI + 중복 클릭 방지

1 participant